《Spring实战》学习笔记-第七章:Spring MVC进阶

本章主要内容:

  • 备用的Spring MVC配置项
  • 处理文件上传
  • 控制器中的异常处理
  • 使用flash属性

“等等,客官!不止这些”

也许大家在看电视广告时对上面这句话比较熟悉,广告里通常在已经对商品做了完整的介绍,这时,电视里就会冒出这句:等等,客官,还不止这些。。。接着,就会继续吹嘘他们的商品还有更多让你意想不到的功能。

其实,Spring MVC(或者说Spring的每一个模块)就给人一种“不止这些”的感觉,就在你以为已经对Spring MVC的功能有了完备的了解时,又会发现可以利用它做的更多。

在第五章中,我们使用Spring MVC的基本功能以及如何编写控制器来处理各种各样的请求。接着在第六章中创建了JSP和Thymeleaf视图来将model数据对用户进行了展示。也许你会觉得Spring MVC不过如此。但是等等,还不止这些!

本章中会继续讨论Spring MVC,比如编写控制器来处理文件上传,如何处理控制器中的异常,以及如何在model上传递数据从而可以在重定向时使用。

首先,在第五章中使用了AbstractAnnotationConfigDispatcherServletInitializer来设置Spring MVC,并且说了可以使用其他备用设置选择。因此在文件上传和异常处理之前,先来探索一下如何使用其他方式来设置DispatcherServletContextLoaderListener

Spring MVC备用配置

第五章中,通过继承AbstractAnnotationConfigDispatcherServletInitializer来快速地对Spring MVC进行了设置。该类假设你想要一个基础的DispatcherServletContextLoaderListener设置,并且通过Java而不是XML文件来配置Spring。

尽管这样配置对大多数Spring应用都是适用的,但是总有意外,比如你想要除了DispatcherServlet之外的servlet和filter,或者你想对DispatcherServlet做一些进一步的配置,再或者,你想在Servlet3.0之前的版本上部署应用,那么你就要使用传统的web.xml文件对DispatcherServlet进行配置了。

幸运的是,在(garden-variety)普通的AbstractAnnotationConfigDispatcherServletInitializer不适用于你的需求时,还有其他的一些方式供你使用。下面,我们就开始如何定制化的配置DispatcherServlet吧。

DispatcherServlet个性化配置

SpittrWebAppInitializer中所包含的三个方法仅仅是必须重写的三个抽象方法,同时还有许多其他方法可以重写从而可以实现更多的配置。

其中一个就是customizeRegistration(),在AbstractAnnotationConfigDispatcherServletInitializer注册了DispatcherServlet之后,就会调用customizeRegistration()方法,并根据servlet的注册返回值传送ServletRegistration.Dynamic,通过对customizeRegistration()的重写,就可以对DispatcherServlet进行额外的配置。

比如,在稍后的章节中(7.2),你会看到Spring MVC如何处理多个请求和文件上传。如果打算使用Servlet3.0来实现多部分配置,那么就需要激活DispatcherServlet配置来实现多路请求。可以使用下面的方式重写customizeRegistration()方法:

1
2
3
4
5
@Override
protected void customizeRegistration(Dynamic registration) {
registration.setMultipartConfig(
new MultipartConfigElement("/tmp/spittr/uploads"));
}

其中ServletRegistration.Dynamic作为入参,你可以做很多事情,比如调用setLoadOnStartup()来设置加载时优先级,调用setInitParameter()来设置初始化参数,调用setMultipartConfig()来设置Servlet3.0的多路支持。在上述示例中,设置了多路支持的上传文件临时存储路径为:/tmp/spittr/uploads。

添加额外的servlet和filter

根据之前的配置,可以生成DispatcherServlet和ContextLoaderListener,但是你需要注册额外的servlet、filter或者listener时怎么办呢?

使用基于Java配置的一个好处就是你可以尽量多的定义初始化类。因此,如果需要定义额外的组件,只需新建相应的初始化类即可。最简单的方法就是实现Spring的WebApplicationInitializer接口。

例如,下面的代码展示了如何通过实现WebApplicationInitializer接口的方式来注册一个servlet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRegistration.Dynamic;
import org.springframework.web.WebApplicationInitializer;
import com.myapp.MyServlet;
public class MyServletInitializer implements WebApplicationInitializer {
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
// 定义servlet
Dynamic myServlet = servletContext.addServlet("myServlet", MyServlet.class);
// 映射servlet
myServlet.addMapping("/custom/**");
}
}

上述代码仅仅是一个基本的servlet注册初始化类,实现了对servlet的注册并映射到一个路径。你也可以使用这种方式来手动地注册DispatcherServlet(不过这好像没有必要,因为AbstractAnnotationConfigDispatcherServletInitializer在这方面已经做得很不错了)。

同样的,你也可以通过上述方式来注册listener和filter。例如:

1
2
3
4
5
6
7
@Override
public void onStartup(ServletContext servletContext) throws ServletException {
// 注册一个filter
javax.servlet.FilterRegistration.Dynamic filter = servletContext.addFilter("myFilter", MyFilter.class);
// 添加映射
filter.addMappingForUrlPatterns(null, false, "/custom/*");
}

WebApplicationInitializer是一个在注册servlet、filter、listener时比较推荐的方式,当然你是使用基于Java的配置方式并将应用部署在Servlet3.0容器上的。如果你仅仅需要注册一个filter并将其映射到DispatcherServlet,那么使用AbstractAnnotationConfigDispatcherServletInitializer将是一个捷径。

要注册多个filter并将它们映射到DispatcherServlet,你所要做的仅仅是重写getServletFilters()方法。比如:

1
2
3
4
@Override
protected Filter[] getServletFilters() {
return new Filter[] { new MyFilter() };
}

如你所见,该方法返回了一个javax.servlet.Filter的数组,这里仅仅返回了一个filter,但是它可以返回很多个。同时这里不再需要为这些filter去声明映射,因为通过getServletFilters()返回的filter会自动地映射到DispatcherServlet。

当部署到Servlet3.0的容器时,Spring提供了很多方法来注册servlet、filter和listener,而不再需要web.xml。如果你使用的不是Servlet3.0版本的容器,或者你就喜欢使用基于web.xml的配置方式,那么该如何对Spring MVC进行配置呢?

使用web.xml声明DispatcherServlet

下面是一个典型的web.xml文件,其中对DispatcherServlet和ContextLoaderListener进行了声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>/WEB-INF/spring/root-context.xml</param-value>
</context-param>
<listener>
<!-- 注册ContextLoaderListener -->
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<servlet>
<servlet-name>appServlet</servlet-name>
<!-- 注册DispatcherServlet -->
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
</servlet>
<!-- DispatcherServlet映射 -->
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>

正如在第五章中所说的,DispatcherServlet和ContextLoaderListener可以加载Spring应用上下文。contextConfigLocation上下文参数指定了用来定义由ContextLoaderListener加载的根应用上下文的XML文件的位置。DispatcherServlet用来通过文件中定义的bean(名称基于指定的servlet名称:appServlet)来加载应用上下文。因此,DispatcherServlet会从/WEB-INF/appServlet-context.xml文件中加载应用上下文。

如果你想指定DispatcherServlet配置文件的位置,那么可以通过设置contextConfigLocation初始化参数的方式实现。例如,下面的DispatcherServlet配置就会从/WEB-INF/spring/appServlet/servlet-context.xml文件中加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<servlet>
<servlet-name>appServlet</servlet-name>
<!-- 注册DispatcherServlet -->
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>
/WEB-INF/spring/appServlet/servlet-context.xml
</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
```
本书中采用的都是基于Java的配置方式,所以你需要对Spring MVC进行设置从而可以从`@Configuration`注解的类中加载配置。为了使用基于Java的配置,需要通知DispatcherServlet和ContextLoaderListener去使用AnnotationConfigWebApplicationContext,该类是`WebApplicationContext`接口的实现类,它可以对Java配置类进行加载。可以通过设置DispatcherServlet的`contextClass`参数和初始化参数来实现。下面对web.xml进行配置从而可以使用Java配置:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee
http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<!-- 使用Java配置 -->
<context-param>
<param-name>contextClass</param-name>
<param-value>
org.springframework.web.context.support.AnnotationConfigWebApplicationContext
</param-value>
</context-param>

<!-- 指定所使用的Java配置类 -->
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>spittr.config.RootConfig</param-value>
</context-param>
<listener>
<listener-class>
org.springframework.web.context.ContextLoaderListener
</listener-class>
</listener>
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<!-- 使用Java配置 -->
<init-param>
<param-name>contextClass</param-name>
<param-value>
org.springframework.web.context.support.AnnotationConfigWebApplicationContext
</param-value>
</init-param>
<!-- 指定DispatcherServlet的配置类 -->
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>
spittr.config.WebConfigConfig
</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>appServlet</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
</web-app>

以上就是配置Spring MVC的一些方法,下面来看如何使用Spring MVC处理文件上传。

处理multipart表单数据

一个web应用通常都会允许用户上传内容,比如像Facebook、Flickr这样的站点,都会允许用户上传小片的。我们的Spittr应用中在两处会用到文件上传:一是新用户注册的时候,这时需要选择一个头像之类的;还有就是当用户新建一个Spittle(推文?)时,也许需要在文中插入一张图片。

来自传统的表单提交的请求结果一般比较简单并且采用多个键值对的方式。例如,当提交一个注册信息的表单时,请求会是这样的:
firstName=Charles&lastName=Xavier&email=professorx%40xmen.org&username=professorx&password=letmein01

虽然这种编码方式对于传统的基于文本的提交是最够的,但是它却没有强大到可以携带二进制数据,比如上传一个图像。相反的,Multipart/form-data将表单分割成独立的部分,每个部分都有各自的类型。传统的表单域都有文本数据,但是当要上传一些东西时,该部分可以是二进制的,如下面的multipart请求体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="firstName"
Charles
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="lastName"
Xavier
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="email"
charles@xmen.com
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="username"
professorx
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="password"
letmein01
------WebKitFormBoundaryqgkaBn8IHJCuNmiW
Content-Disposition: form-data; name="profilePicture"; filename="me.jpg"
Content-Type: image/jpeg
[[ Binary image data goes here ]]
------WebKitFormBoundaryqgkaBn8IHJCuNmiW--

在这个multipart请求中,值得注意的,profilePicture部分是与其他部分不同的,它有一个Content-Type头部用来表示这是一个JPEG图像。虽然不是很明显,profilePicture的内容是一个二进制数据而不是简单文本。

虽然multipart请求看起来比较复杂,但是在Spring MVC中处理起来还是比较简单的。在编写控制器方法来处理文件上传之前,还需要配置一个multipart解析器来告知DispatcherServlet如何读取multipart请求。

配置multipart解析器

DispatcherServlet并没有实现任何逻辑用来将数据转换成multipart请求。它使用了Spring的MultipartResolver接口的实现类来解析multipart请求中的内容。从Spring3.1开始,Spring提供了两种MultipartResolver实现类供选择:

  • CommonsMultipartResolver:使用Jakarta Commons FileUpload来解析multipart请求;
  • StandardServletMultipartResolver:依靠Servlet 3.0支持来解析(Spring 3.1及以上);

一般来讲,StandardServletMultipartResolver应该是第一选择。它使用servlet容器中现有的支持,并且不需要其他附加的项目依赖。但是,如果你将应用部署在Servlet 3.0之前的版本,或者你没有使用Spring3.1及以上版本,那么就要使用CommonsMultipartResolver

使用Servlet 3.0解析multipart请求

StandardServletMultipartResolver没有构造器参数和属性需要设置,这样它的设置就比较简单,就像在Spring配置文件中声明一个bean:

1
2
3
4
@Bean
public MultipartResolver multipartResolver() {
return new StandardServletMultipartResolver();
}

也许你想这么简单的方法,我该如何加一下限制呢?比如,如何限制一个用户可以上传的文件大小,或者如何设置上传过程中文件的临时存放位置。因为没有构造器和属性可以设置,StandardServletMultipartResolver好像是有限制的。

其实是有办法来设置StandardServletMultipartResolver的,但是它的设置不是在Spring配置中进行的,而是在Servlet配置中。起码要配置一下存放临时文件的位置,进一步来讲,还要将multipart配置为DispatcherServlet的一部分。

如果你是在继承自WebMvcConfigurerAdapter的servlet初始化类中配置的DispatcherServlet,那么就可以在servlet注册时通过调用setMultipartConfig()方法来配置multipart详情。比如:

1
2
3
4
DispatcherServlet ds = new DispatcherServlet();
Dynamic registration = context.addServlet("appServlet", ds);
registration.addMapping("/");
registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/uploads"));

如果你是在继承自AbstractAnnotationConfigDispatcherServletInitializer或者AbstractDispatcherServletInitializer的servlet初始化类进行的配置,没有创建DispatcherServlet的实例或者使用servlet上下文对其进行注册。因此就没有直接的引用供Dynamicservlet注册来使用。但是你可以重写customizeRegistration()方法来进行配置:

1
2
3
4
@Override
protected void customizeRegistration(Dynamic registration) {
registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/uploads"));
}

MultipartConfigElement的唯一参数设置了上传文件时临时文件的存放位置。也可以进行其他一些设置:

  • 文件上传的最大值(byte),默认没有限制;
  • 所有multipart请求的文件最大值(byte),不管有多少个请求,默认无限制;
  • 直接上传文件(不需存储到临时目录)的最大值(byte),默认是0,也就是所有的文件都要写入硬盘;

例如,你想设置文件大小不超过2MB,所有请求的总和不超过4MB,并且所有文件都要写入硬盘,那么就可以这样设置:

1
2
3
4
@Override
protected void customizeRegistration(Dynamic registration) {
registration.setMultipartConfig(new MultipartConfigElement("/tmp/spittr/uploads", 2097152, 4194304, 0));
}

如果你是使用的传统的web.xml的方式来设置的DispatcherServlet,那么就需要使用多个<multipart-config>元素,其默认值和MultipartConfigElement相同,并且<location>是必填项:

1
2
3
4
5
6
7
8
9
10
<servlet>
<servlet-name>appServlet</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<multipart-config>
<location>/tmp/spittr/uploads</location>
<max-file-size>2097152</max-file-size>
<max-request-size>4194304</max-request-size>
</multipart-config>
</servlet>

配置Jakarta Commons FileUpload解析器

最简单的CommonsMultipartResolver声明方式是这样的:

1
2
3
4
@Bean
public MultipartResolver multipartResolver() {
return new CommonsMultipartResolver();
}

与StandardServletMultipartResolver不同的是,它不需要配置一个临时目录。默认情况下会使用servlet容器的临时目录。但是,你也可以通过uploadTempDir属性进行设置,同时还可以对其他参数进行设置:

1
2
3
4
5
6
7
8
@Bean
public MultipartResolver multipartResolver() throws IOException {
CommonsMultipartResolver multipartResolver = new CommonsMultipartResolver();
multipartResolver.setUploadTempDir(new FileSystemResource("/tmp/spittr/uploads"));
multipartResolver.setMaxUploadSize(2097152);
multipartResolver.setMaxInMemorySize(0);
return multipartResolver;
}

这里设置了文件的最大大小为2MB,最大的内存中大小为0,即每个上传文件都会直接写入磁盘的。但是它是无法设置multipart请求总的文件大小的。

处理multipart请求

通过上面的配置,Spring已经支持multipart请求,那么就可以开始编写控制器来处理文件上传了。最普遍的做法就是使用@RequestPart注解一个控制器参数。

假设你想让用户可以在注册时上传图像,那么就需要对注册表单进行更改从而用户可以选择一个图片,同时还需要更改SpitterController中的processRegistration()方法以获取上传的文件。下面的代码是使用Thymeleaf的注册页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
  <form method="POST" th:object="${spitter}" enctype="multipart/form-data">
...
<label>Profile Picture</label>:
<input type="file" name="profilePicture" accept="image/jpeg,image/png,image/gif" /><br/>
<input type="submit" value="Register" />
...
```
可以发现`<form>`标签多了`enctype="multipart/form-data"`属性,该属性会告知浏览器要将当前form作为multipart数据处理。

除此之外,还添加了一个新的file类型的`<input>`标签,该标签允许用户选择一个图片进行上传。`accept`属性设置了允许选择的图片类型。根据它的`name`属性,图片数据会放在`profilePicture`部分进行发送。

现在所需做的就是更新`processRegistration()`方法,来获取上传的图片,其中一种方法就是添加一个用`@RequestPart`注解的byte数组:
```java
@RequestMapping(value = "/register", method = RequestMethod.POST)
public String processRegistration(@RequestPart("profilePicture") byte[] profilePicture, @Valid Spitter spitter,
Errors errors) {

当注册表单提交时,请求部分的数据就会赋予到profilePicture属性中,如果用户没有选中一个文件,那么该数组就会是一个空值(不是null)。既然已经获取到上传的文件,下面所需要的就是将文件保存。

接收multipart文件

处理上传文件的原始数据比较简单但是是有局限的,因此,Spring提供了MultipartFile,使用它可以获取到富对象从而更好地处理multipart数据,下面就是MultipartFile接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package org.springframework.web.multipart;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
public interface MultipartFile {
String getName();
String getOriginalFilename();
String getContentType();
boolean isEmpty();
long getSize();
byte[] getBytes() throws IOException;
InputStream getInputStream() throws IOException;
void transferTo(File dest) throws IOException;
}

MultipartFile提供获取上传文件的方法,同时提供了很多其他方法,比如原始文件名称、大小和内容类型等。另外还提供了一个InputStream可以将文件数据作为数据流读取。

另外,MultipartFile还提供了一个方便的transferTo()方法帮助你将上传文件写入到文件系统。例如,你可以将如下代码加入到processRegistration()中:

1
profilePicture.transferTo(new File("/data/spittr/" + profilePicture.getOriginalFilename()));

像这样将文件保存到本地文件系统非常简单,但是将文件管理的工作留给了你。你需要保证有足够的空间,保证对文件进行了备份以防硬件问题。同事还需要进行多服务器之间的文件同步。

将文件保存到Amazon S3

另外的办法就是将上面这些都托管给其他人,可以存放在云端,下面的代码可以将上传的图像保存到Amazon S3:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private void saveImage(MultipartFile image) throws ImageUploadException {
try {
AWSCredentials awsCredentials = new AWSCredentials(s3AccessKey, s2SecretKey);
// 配置S3服务
S3Service s3 = new RestS3Service(awsCredentials);
// 创建S3 bucket对象
S3Bucket bucket = s3.getBucket("spittrImages");
S3Object imageObject = new S3Object(image.getOriginalFilename());
// 设置图像数据
imageObject.setDataInputStream(image.getInputStream());
imageObject.setContentLength(image.getSize());
imageObject.setContentType(image.getContentType());
AccessControlList acl = new AccessControlList();
// 设置权限
acl.setOwner(bucket.getOwner());
acl.grantPermission(GroupGrantee.ALL_USERS, Permission.PERMISSION_READ);
imageObject.setAcl(acl);
// 保存图片
s3.putObject(bucket, imageObject);
} catch (Exception e) {
throw new ImageUploadException("Unable to save image", e);
}

saveImage()的第一步就是设置Amazon Web Service (AWS)认证,你需要提供S3的密钥和私钥,这些在注册S3服务时Amazon都会给你的。

认证过AWS之后,saveImage()创建了一个JetS3t的RestS3Service实例,可以通过它操作S3文件系统。它会获取一个spittrImages的bucket引用,并创建用于包含图标的S3Object对象,然后将突破数据填充到S3Object中。

在调用putObject()方法将图片数据写入S3之前,saveImage()方法设置了S3Object的权限,允许有所有用户查看。这很重要,因为如果没有设置的话,那么这些图片对于应用程序的用户来说都是不可见得了。如果出现什么问题的话,会抛出ImageUploadException异常。

接收上传文件为Part

如果你将应用部署在Servlet 3.0的容器上,那么你可以选择不使用MultipartFile,Spring MVC也可以将javax.servlet.http.Part作为控制器的入参,使用Part后processRegistration()方法就是这样的了:

1
2
3
@RequestMapping(value = "/register", method = RequestMethod.POST)
public String processRegistration(@RequestPart("profilePicture") Part profilePicture, @Valid Spitter spitter,
Errors errors) {

大多数情况下Part接口和MultipartFile没什么区别,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package javax.servlet.http;
import java.io.*;
import java.util.*;

public interface Part {
public InputStream getInputStream() throws IOException;
public String getContentType();
public String getName();
public String getSubmittedFileName();
public long getSize();
public void write(String fileName) throws IOException;
public void delete() throws IOException;
public String getHeader(String name);
public Collection<String> getHeaders(String name);
public Collection<String> getHeaderNames();
}

一些方法就是名称上的不同,比如getSubmittedFileName()getOriginalFilename()是对应的。write()transferTo()是对应的,可以这样使用:
profilePicture.write("/data/spittr/" + profilePicture.getOriginalFilename());

值得注意的是,如果你使用Part作为参数,那么就不再需要配置StandardServletMultipartResolverbean,它只需在使用MultipartFile时进行配置。

异常处理

一直以来我们都是假设Spittr应用中的一切都是正常运行的,但是如果哪里出现错误了呢?或者在处理请求时出现了异常?这时该向客户端发送什么响应呢?

不论发生什么,好的或者坏的,一个servlet请求的输出只能是一个servlet响应。如果在处理请求的过程中出现异常,输出结果仍然是一个servlet响应,需要将异常转换为一个响应。

Spring提供了一些将异常转化为响应的方法:

  • 某些Spring异常会自动的映射为特定的HTTP状态码;
  • 使用@ResponseStatus注解将一个异常映射为HTTP状态码;
  • 使用ExceptionHandler注解的方法可以用来处理异常

映射异常为HTTP状态码

Spring可以自动地将其异常映射为状态码,如下表:

Spring异常 HTTP状态码
BindException 400 - Bad Request
ConversionNotSupportedException 500 - Internal Server Error
HttpMediaTypeNotAcceptableException 406 - Not Acceptable
HttpMediaTypeNotSupportedException 415 - Unsupported Media Type
HttpMessageNotReadableException 400 - Bad Request
HttpMessageNotWritableException 500 - Internal Server Error
HttpRequestMethodNotSupportedException 405 - Method Not Allowed
MethodArgumentNotValidException 400 - Bad Request
MissingServletRequestParameterException 400 - Bad Request
MissingServletRequestPartException 400 - Bad Request
NoSuchRequestHandlingMethodException 404 - Not Found
TypeMismatchException 400 - Bad Request

表格里的异常通常是在DispatcherServlet中出错由Spring自身抛出的。例如,如果DispatcherServlet无法找到合适的控制器来处理请求,那么就会抛出NoSuchRequestHandlingMethodException,对应的状态码就是404。

虽然这些内置的映射有点用,但是不一定适用于其他的应用异常。还好,Spring提供了@ResponseStatus注解将一个异常映射为HTTP状态码。

比如下面SpittleController中的请求处理方法就可以返回HTTP 404状态:

1
2
3
4
5
6
7
8
9
@RequestMapping(value = "/{spittleId}", method = RequestMethod.GET)
public String spittle(@PathVariable("spittleId") long spittleId, Model model) {
Spittle spittle = spittleRepository.findOne(spittleId);
if (spittle == null) {
throw new SpittleNotFoundException();
}
model.addAttribute(spittle);
return "spittle";
}

如果findOne()方法返回了一个null,那么就会抛出SpittleNotFoundException。这里,SpittleNotFoundException就是一个未经检查的异常:

1
2
3
4
5
package spittr.web;

public class SpittleNotFoundException extends Exception {

}

如果在处理请求时调用了spittle()方法,并且传入的ID是空的,那么SpittleNotFoundException就会默认产生500的响应。实际上,如果没有找到对应的映射都会返回500的错误。但是你也可以通过对SpittleNotFoundException进行映射改变这种情况。

当抛出SpittleNotFoundException时就表示一个请求的资源不存在,404恰好符合这种情况。那么,我们就使用@ResponseStatus来将其映射到404。

1
2
3
4
5
6
7
8
9
package spittr.web;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(value=HttpStatus.NOT_FOUND, reason="Spittle Not Found")
public class SpittleNotFoundException extends Exception {

}

编写异常处理方法

将异常映射为状态码大多数情况下是比较简单有效的,但是如果想让响应不仅仅只有一个状态码呢?也许你想对异常进行一些处理,就行处理请求一样。

例如,SpittleRepository的save()方法在用户重复创建Spittle时抛出了一个DuplicateSpittleException,那么SpittleController的saveSpittle()方法就需要处理该异常。如下面的代码所示,saveSpittle()方法可以直接处理该异常:

1
2
3
4
5
6
7
8
9
10
@RequestMapping(method = RequestMethod.POST)
public String saveSpittle(SpittleForm form, Model model) {
try {
spittleRepository.save(new Spittle(null, form.getMessage(),
new Date(), form.getLongitude(), form.getLatitude()));
return "redirect:/spittles";
} catch (DuplicateSpittleException e) {
return "error/duplicate";
}
}

上面的代码并没有什么特别的,这就是一个简单的Java异常处理。

这样做还可以,但是这个方法有点复杂。如果saveSpittle()方法专注于业务处理,让其他方法来处理异常该多好。下面就为SpittleController添加一个新的方法来处理DuplicateSpittleException异常:

1
2
3
4
@ExceptionHandler(DuplicateSpittleException.class)
public String handleDuplicateSpittle() {
return "error/duplicate";
}

@ExceptionHandler注解应用在handleDuplicateSpittle()方法上,用来指定在有DuplicateSpittleException异常抛出时执行。

有意思的是,@ExceptionHandler注解的方法在同一个控制器里是通用的额,即无论SpittleController的哪一个方法抛出DuplicateSpittleException异常,handleDuplicateSpittle()方法都可以对其进行处理,而不再需要在每一个出现异常的地方进行捕获。

也许你在想,@ExceptionHandler注解的方法能不能捕获其他controller里的异常啊?在Spring3.2里是可以的,但仅仅局限于定义在控制器增强类(controller advice class)里的方法。

那么什么是控制器增强类呢?下面我们就来看看这个控制器增强类。

控制器增强类(controller advice class)

如果controller类的特定切面可以跨越应用的所有controller进行使用,那么这将会带来极大的便捷。例如,@ExceptionHandler方法就可以处理多个controller抛出的异常了。如果多个controller类都抛出同一个异常,也许你会在这些controller进行重复的@ExceptionHandler方法编写。或者,你也可以编写一个异常处理的基类,供其他@ExceptionHandler方法进行继承。

Spring3.2带来了另外一种处理方法:控制器增强类,即使用@ControllerAdvice进行注解的类,它们会有下面几个方法构成:

  • @ExceptionHandler注解的
  • @InitBinder注解的
  • @ModelAttribute注解的

@ControllerAdvice注解的类中的这些方法会在整个应用中的所有controller的所有@RequestMapping注解的方法上应用。

@ControllerAdvice注解本身是使用了@Component注解的,因此,使用@ControllerAdvice注解的类会在组件扫描时进行提取,就行使用@Controller注解的类一样。

@ControllerAdvice的最实用的一个功能就是将所有的@ExceptionHandler方法集成在一个类中,从而可以在一个地方处理所有controller中的异常。例如,假设你想处理应用中所有的DuplicateSpittleException异常,可以采用下面的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package spittr.web;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

// 声明控制器增强
@ControllerAdvice
public class AppWideExceptionHandler {

// 定义异常处理方法
@ExceptionHandler(DuplicateSpittleException.class)
public String handleDuplicateSpittle() {
return "error/duplicate";
}

@ExceptionHandler(SpittleNotFoundException.class)
public String handleSpittleNotFound() {
return "error/duplicate";
}

}

现在,不论哪一个controller抛出DuplicateSpittleException,都会调用handleDuplicateSpittle()方法来处理。

在redirect请求中携带数据

正如前文提到的,在处理完一个POST请求后进行重定向是一个不错的选择,起码这样可以避免用户点击刷新造成的POST请求重发的问题。

在第五章中,已经在控制器方法返回的视图名称中使用了redirect:前缀,这时返回的String不是用来寻找视图,而是浏览器进行跳转的路径:
return "redirect:/spitter/" + spitter.getUsername();

也许你认为Spring处理重定向只能这样了,但是等等:Spring还可以做得更多。

特别是一个重定向方法如何向处理重定向的方法发送数据呢?一般的,当一个处理函数结束后,方法中的model数据都会作为request属性复制到request中,并且request会传递到视图中进行解析。因为控制器和视图面对的是同一个request,因此request属性在forward时保留了下来。

但是,当一个控制器返回的是一个redirect时,原来的request会终止,并且会开启一个新的HTTP请求。原来request中所有的model数据都会清空。新的request不会有任何的model数据。

Model属性会作为request的属性但是不能再redirect中传递

明显的,现在不能再redirect时使用model来传递数据了。但是还有其他方法用来从重定向的方法中获取数据:

  • 将数据转换为路径参数或者查询参数
  • 在flash属性中发送数据
    首先来看一下Spring如何在路径参数或者查询参数中传递数据。

使用URL模版重定向

将数据转化为路径参数和查询参数看起来比较简单。在之前的代码里,新建的Spitter的username就是作为路径参数进行传递的。但是这里的username是转换为String进行传递的。使用String传递URL和SQL时是比较危险的事情。

除了使用重定向链接,Spring提供了使用模版来定义重定向链接。例如下面的代码:
return "redirect:/spitter/{username}";

你所需做的就是设置model中的相关值。因此,processRegistration()方法需要接收model作为入参,并将username设置其中。

1
2
3
4
5
6
@RequestMapping(value="/register", method=POST)
public String processRegistration(Spitter spitter, Model model) {
spitterRepository.save(spitter);
model.addAttribute("username", spitter.getUsername());
return "redirect:/spitter/{username}";
}

由于这里使用了占位符而不是直接使用重定向String进行连接,就可以将username中的不安全字符隐藏起来。这样就比让用户直接输入username并将其添加到路径后面要更加安全。

另外,model中其他的原始值也会作为查询参数添加到重定向URL中。例如,除了username,model同时也包括新建的Spitter对象的id属性:

1
2
3
4
5
6
7
@RequestMapping(value="/register", method=POST)
public String processRegistration(Spitter spitter, Model model) {
spitterRepository.save(spitter);
model.addAttribute("username", spitter.getUsername());
model.addAttribute("spitterId", spitter.getId());
return "redirect:/spitter/{username}";
}

返回的重定向String并没有什么变化,但是由于model中的spitterId属性并没有映射到URL中的占位符,它会自动作为查询参数。

如果username是habuma,spitterId是42,那么返回的重定向路径将是/spitter/habuma?spitterId=42

使用路径参数和查询参数传递数据比较简单,但是它也有局限性。它只适用于传递简单值,比如String和数字,不能传递比较复杂的东西,那么我们就需要flash属性来帮忙。

使用flash属性

比如说你不再是想在重定向中传送一个username或者ID,而是传送一个真正的Spitter对象。如果只传送了一个ID,那么处理重定向的方法不得不去数据库中查找该对象。但是在重定向之前你已经有有一个Spitter对象了,为什么不将它传送给重定向处理方法呢?

Spitter对象不像String或者int那么简单,因此不能作为路径参数或者查询参数进行传送。但是,它可以作为model的一个属性。

但是在上面的讨论中,model属性最终都会拷贝到request中,并随着redirect的触发而消失。因此,你需要将Spitter对象放在一个会随着redirect存活的地方。

其中一个方法是将其放在session中,session是可以长期存活的,可以跨越多个request。因此,你可以将Spitter对象在redirect之前放在session中,并在redirect之后取出。当然你还要在取出之后将其从session中清理。

事实证明,Spring允许将数据存放在session中,从而在redirect时传递数据。但是Spring认为你不应该负责管理这些数据。相反,Spring提供了将数据作为flash属性进行传送的功能。Flash属性,即在到下一个request之前一直携带数据,然后它们就走了。

Spring提供了通过RedirectAttributes来设置flash属性,RedirectAttributes作为Model的子接口,新增了一些方法用来设置flash属性。

特别的,RedirectAttributes提供了addFlashAttribute()方法用来添加flash属性。那么就可以利用它来重写processRegistration()方法:

1
2
3
4
5
6
7
@RequestMapping(value="/register", method=POST)
public String processRegistration(Spitter spitter, RedirectAttributes model) {
spitterRepository.save(spitter);
model.addAttribute("username", spitter.getUsername());
model.addFlashAttribute("spitter", spitter);
return "redirect:/spitter/{username}";
}

这里,可以调用addFlashAttribute()方法将Spitter对象作为一个值添加到flash属性中。另外,你也可以不填对应的key值:
model.addFlashAttribute(spitter);
由于你传递了一个Spitter对象,因此key会自动生成为spitter

在重定向之前,所有的flash属性都会拷贝到session中,在重定向之后,存储在session中的flash属性会从session中移出到model中。然后处理重定向请求的方法就可以使用Spitter对象了,如下图所示:

flash属性都会拷贝到session中,然后转存到model中

下面对showSpitterProfile()进行一点点更,在从数据库查找之前对Spitter进行检查:

1
2
3
4
5
6
7
8
@RequestMapping(value = "/{username}", method = RequestMethod.GET)
public String showSpitterProfile(@PathVariable("username") String username, Model model) {
if (!model.containsAttribute("spitter")) {
Spitter spitter = spitterRepository.findByUsername(username);
model.addAttribute(spitter);
}
return "profile";
}

正如你所见,该方法的第一件事是检查model中是否含有spitter的属性,如果有就啥也不做了。Spitter对象会被直接传送到视图中进行解析。如果没有再去数据库里查。

总结

每当使用Spring时,好像总有更多:更多的特性、更多的选择以及更多的途径可以达到目标,Spring MVC有很多花样繁多的功能。

Spring MVC的配置就是一个你需要进行选择的地方。本章中,我们从如何配置Spring MVC的DispatcherServletContextLoaderListener说起。你可以看到如何进行DispatcherServlet注册以及其他的servlet和filter的注册。另外,如果将应用部署在比较旧的容器上,我们还可以使用web.xml进行配置。

接着,我们看了如何处理Spring MVC控制器抛出的异常。尽管@RequestMapping方法可以处理异常,如果你将异常处理部分抽取出来那么你的代码就会比较清爽。

为了完成通用的任务,比如异常处理,会在整个应用中使用,Spring3.2开始提供了@ControllerAdvice来创建增强型控制器,从而可以在一个地方完成通用的异常处理。

最后,我们研究了如何在重定向时传递数据,那就是使用Spring的flash属性。

至此,也许你会觉得,不过如此嘛!但是我们讨论的仅仅是Spring MVC功能的一小部分。在16章中我们还会讨论其他功能,比如如何利用它来创建REST API。

下面的章节,我们先放一放Spring MVC,来看一下Spring Web Flow,这是一个流框架,是Spring MVC的扩展,它能够在Spring中实现面向会话的Web开发。

hoxis wechat
一个脱离了高级趣味的程序员,关注回复1024有惊喜~
赞赏一杯咖啡
0%